# Paged results for vBulletin
# by Cee Pee

from deps.Filter import filter_none
import forumapi
import math
import re
import urllib.parse

class PagedResult(forumapi.Mergeable, forumapi.Data):
	"""Represents a collection of objects that represent various data, but can
	only be retrieved by sending multiple requests to numbered pages on the
	server.
	
	Makes sure that the objects are kept and that no useless requests are made.
	
	See forumapi.vbulletin's `Session.get_pages()` for more information."""
	
	def __init__(self, session, path, callback, query={}, method="get", pages=None, per_page=None, length=None):
		self.session = session
		self.path = path
		self.callback = callback
		
		self.query = query
		self.method = method
		
		self.result = {}
		self.current_page = None
		
		self._pages = pages
		self._per_page = per_page
		self._length = length
	
	def __getitem__(self, key):
		try:
			item = int(key)
		except:
			raise(KeyError(key))
		
		(page, offset) = self.get_offset(item)
		
		try:
			self.get_pages(pages=1, start=page)
			
			return(self.result[page][offset])
		except:
			raise(KeyError(key))
	
	def __contains__(self, key):
		return(key in self)
	
	def get_offset(self, item):
		"""Determines the page and offset for a given item key.
		
		Returns a `(page, offset)` tuple, which can be used to retrieve the
		item data."""
		
		if item < 0:
			item += self.length
		if item < 0:
			item = 0
		
		if item == 0:
			page = 1
			offset = 0
		else:
			page = math.ceil(item / self.per_page)
			offset = item - ((page - 1) * self.per_page)
		
		return((page, offset))
	
	def get_pages(self, path=None, query=None, response=None, pages=1, start=1, **kw):
		"""Retrieves data from pages, and adds each page's data to
		`self.result`. `path` and `query` can be used to temporarily override
		`self.path` and `self.query`, respectively, or a `response` of the
		first page can be given, from which the path and query will be reused.
		
		The `pages` and `start` arguments determine how many pages to retrive,
		and the page to start at. `pages` can be set to `"all"` to get all
		pages, or `None` to do so only if additional keyword arguments are
		given, which are passed to `self.callback` to find a certain object."""
		
		if pages is None:
			if kw and len(filter_none(kw)) != 0:
				# TODO: This retrieves all pages if the object to be found
				# happens to not be in the list
				pages = "all"
			else:
				pages = 1
		
		if (pages != "all" and pages < 1) or start < 1:
			raise(ValueError("Page numbers must be greater than zero."))
		
		if self._pages is not None and start > self.pages:
			raise(ValueError("First page is out of range."))
		
		if path is None:
			if response:
				# Get the path to be reused for next pages, remove the slash
				# We do it this way to be compatible with search
				url = urllib.parse.urlparse(response.response.geturl())
				self.path = url.path[1:]
				self.query.update(urllib.parse.parse_qs(url.query))
			
			path = self.path
		
		if query is None:
			query = self.query.copy()
		
		first_page = True
		continue_pages = True
		
		self.current_page = start
		if pages != "all":
			pages += start - 1
		
		while (pages == "all" or self.current_page <= pages) and continue_pages:
			if self.current_page not in self.result:
				if not(response and first_page):
					query["page"] = self.current_page
					if self.current_page == 1:
						method = self.method
					else:
						method = "get"
					
					response = self.session.get_page(path, post_data=query, method=method)
				
				data = []
				page_soup = self.session.soup(response)
				
				continue_pages = self.callback(self, data, page_soup, first_page=first_page, session=self.session, **kw)
				
				if self._pages is None or self._per_page is None or self._length is None:
					self.get_pager(page_soup)
				
				if self._pages is None:
					self._pages = 1
				
				if start > self.pages:
					raise(ValueError("First page is out of range."))
				
				self.result[self.current_page] = data
				
				first_page = False
				
				if pages == "all":
					pages = self.pages
				else:
					pages = min(pages, self.pages)
				
				if self._per_page is None:
					# TODO: Can be wrong if this is the last page (unlikely)
					self._per_page = len(self.result[self.current_page])
				if self._length is None:
					# TODO: Approximate
					self._length = self.per_page * self.pages
			
			self.current_page += 1
			
			# TODO: Is this needed?
			if self.current_page >= self.pages:
				#self._length = self.per_page * self.pages - (self.per_page - len(self.result[self.current_page])
				break
	
	_numpages_pattern = None
	
	@property
	def numpages_pattern(self):
		if self._numpages_pattern:
			return(self._numpages_pattern)
		else:
			self.__class__._numpages_pattern = re.compile(self.session.lang.phrases["pager_numpages"])
			return(self._numpages_pattern)
	
	_results_pattern = None
	
	@property
	def results_pattern(self):
		if self._results_pattern:
			return(self._results_pattern)
		else:
			self.__class__._results_pattern = re.compile(self.session.lang.phrases["pager_results"], re.I)
			return(self._results_pattern)
	
	def get_pager(self, soup):
		"""Given a `BeautifulSoup` of a page or section of a page, searches for
		a vBulletin page navigation control and sets information from it, such
		as the number of pages, the number of items per page, and the total
		number of items across all pages.
		
		If multiple page controls with different values are found, the result of
		the first control is used."""
		
		if soup:
			for table in soup.findAll("table", "tborder"):
				control = table.find("td", "vbmenu_control")
				if control:
					pages = self.numpages_pattern.match(str(control.string).strip())
					if pages:
						self.current_page = self.session.get_number(pages.group(1))
						self._pages = self.session.get_number(pages.group(2))
						
						results = self.results_pattern.match(table.findAll("td")[1].find(attrs={"class": "smallfont"})["title"])
						
						self._per_page = self.session.get_number(results.group(2)) - self.session.get_number(results.group(1)) + 1
						self._length = self.session.get_number(results.group(3))
						
						break
	
	@property
	def pages(self):
		if self._pages is not None:
			return(self._pages)
		else:
			self.get_pages(pages=1)
			return(self._pages)
	
	@property
	def per_page(self):
		if self._per_page is not None:
			return(self._per_page)
		else:
			self.get_pages(pages=1)
			return(self._per_page)
	
	@property
	def length(self):
		if self._length is not None:
			return(self._length)
		else:
			self.get_pages(pages=1)
			return(self._length)
	
	@property
	def items(self):
		items = []
		
		for data in self.result.values():
			items.extend(data)
		
		return(items)
	
	@property
	def data(self):
		return(self.items)
	
	@property
	def first(self):
		return(self[0])
	
	@property
	def last(self):
		return(self[-1])
	
	def remove(self, item):
		"""Removes an item from the retrieved data."""
		
		for data in self.result.values():
			try:
				data.remove(item)
				return
			except ValueError:
				pass
		
		raise(ValueError)
	
	def merge(self, other, reverse=False):
		"""Merges a `PagedResult` object or a dictionary with page numbers as
		keys into this object, making sure that all usable data is retained.
		
		If this is given a list, this will merge the list's items across pages.
		The `reverse` parameter can be given to merge until the last page rather
		than starting at the first page."""
		
		if isinstance(other, PagedResult):
			self._merge_dict(other.result)
		elif isinstance(other, dict):
			self._merge_dict(other)
		elif isinstance(other, list):
			self._merge_list(other, reverse)
		else:
			return(False)
		
		return(True)
	
	def _merge_dict(self, data):
		for page, page_data in data.items():
			if isinstance(page, int) and page not in self.result:
				self.result[page] = page_data
	
	def _merge_list(self, other, reverse):
		if reverse:
			page = self.pages - math.ceil(len(other) / self.per_page) + 1
			if page < 1:
				page = 1
		else:
			page = 1
		
		self.result[page] = []
		i = 0
		for item in other:
			if i > self.per_page:
				page += 1
				self.result[page] = []
				
				i = 1
			else:
				i += 1
			
			self.result[page].append(item)
	
	def __len__(self):
		return(self.length)
	
	def __iter__(self):
		return(PagedIterator(self))
	
	def __reversed__(self):
		return(ReversedPagedIterator(self))
	
	def __repr__(self):
		return("PagedResult({items})".format(items=repr(self.items)))

class PagedIterator(object):
	def __init__(self, pager):
		self.pager = pager
		self.item = -1
	
	def __iter__(self):
		return(self)
	
	def __next__(self):
		try:
			self.item += 1
			return(self.pager[self.item])
		except:
			raise(StopIteration)

class ReversedPagedIterator(PagedIterator):
	def __init__(self, pager):
		self.pager = pager
		self.item = 0
	
	def __next__(self):
		if self.item + len(self.pager) <= 0:
			raise(StopIteration)
		
		try:
			self.item -= 1
			return(self.pager[self.item])
		except:
			raise(StopIteration)
